// Copyright © 2025 Ory Corp
// SPDX-License-Identifier: Apache-2.0

package integration_test

import (
	"fmt"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	goauth "golang.org/x/oauth2"

	"github.com/ory/x/uuidx"

	"github.com/ory/hydra/v2/fosite"
	"github.com/ory/hydra/v2/fosite/compose"
	"github.com/ory/hydra/v2/fosite/internal/gen"
	"github.com/ory/hydra/v2/fosite/token/jwt"
)

func TestDeviceFlow(t *testing.T) {
	t.Run("device auth", deviceAuth)
	t.Run("exchange for access token", exchangeForAccessToken)
}

func deviceAuth(t *testing.T) {
	session := &fosite.DefaultSession{}

	fc := &fosite.Config{
		DeviceVerificationURL: "https://example.com/",
		RefreshTokenLifespan:  -1,
		GlobalSecret:          []byte("some-secret-thats-random-some-secret-thats-random-"),
	}
	f := compose.ComposeAllEnabled(fc, fositeStore, gen.MustRSAKey())
	ts := mockServer(t, f, session)
	defer ts.Close()

	oauthClient := &goauth.Config{
		ClientID:     "device-client",
		ClientSecret: "foobar",
		Endpoint: goauth.Endpoint{
			TokenURL:      ts.URL + tokenRelativePath,
			DeviceAuthURL: ts.URL + deviceAuthRelativePath,
		},
	}
	for _, c := range []struct {
		description string
		setup       func()
		err         bool
		check       func(t *testing.T, token *goauth.DeviceAuthResponse, err error)
		cleanUp     func()
	}{
		{
			description: "should fail with invalid_grant",
			setup: func() {
				fositeStore.Clients["device-client"].(*fosite.DefaultClient).GrantTypes = []string{"authorization_code"}
			},
			err: true,
			check: func(t *testing.T, token *goauth.DeviceAuthResponse, err error) {
				assert.ErrorContains(t, err, "invalid_grant")
			},
			cleanUp: func() {
				fositeStore.Clients["device-client"].(*fosite.DefaultClient).GrantTypes = []string{"urn:ietf:params:oauth:grant-type:device_code", "refresh_token"}
			},
		},
		{
			description: "should fail with invalid_scope",
			setup: func() {
				oauthClient.Scopes = []string{"openid"}
				fositeStore.Clients["device-client"].(*fosite.DefaultClient).Scopes = []string{"profile"}
			},
			err: true,
			check: func(t *testing.T, token *goauth.DeviceAuthResponse, err error) {
				assert.ErrorContains(t, err, "invalid_scope")
			},
			cleanUp: func() {
				oauthClient.Scopes = []string{}
				fositeStore.Clients["device-client"].(*fosite.DefaultClient).Scopes = []string{"fosite", "offline", "openid"}
			},
		},
		{
			description: "should fail with invalid_client",
			setup: func() {
				oauthClient.ClientID = "123"
			},
			err: true,
			check: func(t *testing.T, token *goauth.DeviceAuthResponse, err error) {
				assert.ErrorContains(t, err, "invalid_client")
			},
			cleanUp: func() {
				oauthClient.ClientID = "device-client"
			},
		},
		{
			description: "should pass",
			setup:       func() {},
			err:         false,
		},
	} {
		t.Run(fmt.Sprintf("description=%s", c.description), func(t *testing.T) {
			c.setup()

			resp, err := oauthClient.DeviceAuth(t.Context())
			require.Equalf(t, c.err, err != nil, "got %+v", err)
			if !c.err {
				assert.NotEmpty(t, resp.DeviceCode)
				assert.NotEmpty(t, resp.UserCode)
				assert.NotEmpty(t, resp.Interval)
				assert.NotEmpty(t, resp.VerificationURI)
				assert.NotEmpty(t, resp.VerificationURIComplete)
			}

			if c.check != nil {
				c.check(t, resp, err)
			}

			if c.cleanUp != nil {
				c.cleanUp()
			}
		})
	}
}

func exchangeForAccessToken(t *testing.T) {
	session := newIDSession(&jwt.IDTokenClaims{Subject: "peter"})

	fc := &fosite.Config{
		DeviceVerificationURL:          "https://example.com/",
		RefreshTokenLifespan:           -1,
		GlobalSecret:                   []byte("some-secret-thats-random-some-secret-thats-random-"),
		DeviceAuthTokenPollingInterval: -1,
	}
	f := compose.ComposeAllEnabled(fc, fositeStore, gen.MustRSAKey())
	ts := mockServer(t, f, session)

	for _, c := range []struct {
		description   string
		updateClients func(*fosite.DefaultClient, *goauth.Config)
		params        []goauth.AuthCodeOption
		check         func(t *testing.T, token *goauth.Token, cl *goauth.Config, err error)
	}{
		{
			description: "should fail with invalid grant type",
			params:      []goauth.AuthCodeOption{goauth.SetAuthURLParam("grant_type", "invalid_grant_type")},
			check: func(t *testing.T, _ *goauth.Token, _ *goauth.Config, err error) {
				assert.ErrorContains(t, err, "invalid_request")
			},
		},
		{
			description: "should fail with wrong grant type",
			updateClients: func(cl *fosite.DefaultClient, _ *goauth.Config) {
				cl.GrantTypes = []string{"authorization_code"}
			},
			params: []goauth.AuthCodeOption{},
			check: func(t *testing.T, _ *goauth.Token, _ *goauth.Config, err error) {
				assert.ErrorContains(t, err, "unauthorized_client")
			},
		},
		{
			description: "should fail with invalid device code",
			params:      []goauth.AuthCodeOption{goauth.SetAuthURLParam("device_code", "invalid_device_code")},
			check: func(t *testing.T, _ *goauth.Token, _ *goauth.Config, err error) {
				assert.ErrorContains(t, err, "invalid_grant")
			},
		},
		{
			description: "should fail with invalid client id",
			updateClients: func(_ *fosite.DefaultClient, cl *goauth.Config) {
				cl.ClientID = uuidx.NewV4().String()
			},
			check: func(t *testing.T, _ *goauth.Token, _ *goauth.Config, err error) {
				assert.ErrorContains(t, err, "invalid_client")
			},
		},
		{
			description: "should pass",
			check: func(t *testing.T, token *goauth.Token, cl *goauth.Config, err error) {
				assert.Equal(t, "bearer", token.TokenType)
				assert.NotEmpty(t, token.AccessToken)
				assert.NotEmpty(t, token.RefreshToken)
				assert.NotEmpty(t, token.Extra("id_token"))

				tokenSource := cl.TokenSource(t.Context(), token)
				refreshed, err := tokenSource.Token()
				require.NoError(t, err)

				assert.NotEmpty(t, refreshed.AccessToken)
				assert.NotEmpty(t, refreshed.RefreshToken)
				assert.NotEmpty(t, refreshed.Extra("id_token"))
			},
		},
	} {
		t.Run(fmt.Sprintf("description=%s", c.description), func(t *testing.T) {
			clientID := uuidx.NewV4().String()
			fCl := &fosite.DefaultClient{
				ID:         clientID,
				Secret:     []byte(`$2a$10$IxMdI6d.LIRZPpSfEwNoeu4rY3FhDREsxFJXikcgdRRAStxUlsuEO`), // = "foobar"
				GrantTypes: []string{"urn:ietf:params:oauth:grant-type:device_code", "refresh_token"},
				Scopes:     []string{"fosite", "offline", "openid"},
				Audience:   []string{tokenURL},
				Public:     true,
			}
			cl := &goauth.Config{
				ClientID:     clientID,
				ClientSecret: "foobar",
				Endpoint: goauth.Endpoint{
					TokenURL:      ts.URL + tokenRelativePath,
					DeviceAuthURL: ts.URL + deviceAuthRelativePath,
				},
				Scopes: []string{"openid", "fosite", "offline"},
			}

			fositeStore.Clients[fCl.ID] = fCl

			resp, err := cl.DeviceAuth(t.Context())
			require.NoError(t, err)

			if c.updateClients != nil {
				c.updateClients(fCl, cl)
				fositeStore.Clients[fCl.ID] = fCl
			}

			resp.Interval = 1 // speed up tests
			deviceCodeSignature, err := compose.NewDeviceStrategy(fc).DeviceCodeSignature(t.Context(), resp.DeviceCode)
			require.NoError(t, err)

			req, err := fositeStore.GetDeviceCodeSession(t.Context(), deviceCodeSignature, nil)
			require.NoError(t, err)
			require.NoError(t, fositeStore.CreateOpenIDConnectSession(t.Context(), deviceCodeSignature, req))

			d := fositeStore.DeviceAuths[deviceCodeSignature]
			d.SetUserCodeState(fosite.UserCodeAccepted)
			fositeStore.DeviceAuths[deviceCodeSignature] = d

			t.Parallel()

			token, err := cl.DeviceAccessToken(t.Context(), resp, c.params...)

			c.check(t, token, cl, err)
		})
	}
}
